iT邦幫忙

2024 iThome 鐵人賽

DAY 9
2
Modern Web

為你自己寫 Vue Component系列 第 9 篇

[為你自己寫 Vue Component] AtomicScrollbar

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicScrollbar

在專案中,你會如何設定 Scrollbar 的樣式呢?

當我們想要自定義網頁的 Scrollbar 時,最常見的方式之一就是用 CSS 改寫原生的 Scrollbar 樣式。儘管 CSS 可以做到 Scrollbar 的客製化,但也有不少限制。例如,跨瀏覽器問題:在不同瀏覽器上,我們需要使用不同的前綴甚至不同的方法來實現。

不過,有時候我們還是希望能有更高的可控性與跨瀏覽器的支援度,這時 <AtomicScrollbar> 元件可以滿足我們這些需求。

元件分析

元件架構

AtomicScrollbar 元件架構

  1. Thumb:滑鼠點擊拖曳的部分。
  2. Track:軌道,Thumb 的容器。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Scrollbar 元件是如何設計的。

Element Plus

<ElScrollbar height="400px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ElScrollbar>

Element Plus 提供 <ElScrollbar> 作為容器,高度可透過 props 傳入,這個高度用於定義 <ElScrollbar> 內部 Wrap 的大小。如果內容超出 Wrap,則會啟動 Scrollbar。此外,若希望自定義 Viewport 的 HTML tag,<ElScrollbar> 接受 tag 這個 props 來設定。

PrimeVue

<ScrollPanel style="width: 100%; height: 200px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ScrollPanel>

PrimeVue 的元件名稱為 <ScrollPanel>,使用方式與 Element Plus 基本相同。不同的是,Element Plus 的 height 設定是透過 props,並將其應用於 Wrap 上,而 PrimeVue 則是直接限制最外層元件的大小來達到效果。

Radix Vue

<ScrollAreaRoot style="--scrollbar-size: 10px">
  <ScrollAreaViewport>
    <ul :style="{ padding: '15px 20px' }">
      <li v-for="item in 20" :key="item">{{ item }}</li>
    </ul>
  </ScrollAreaViewport>
  <ScrollAreaScrollbar orientation="vertical">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
  <ScrollAreaScrollbar orientation="horizontal">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
</ScrollAreaRoot>

Radix Vue 的使用方式看似較為複雜,但這其實提供了一個明確的架構指南,讓我們能輕易看到 Scrollbar 元件的組成。經過封裝後,使用起來也和前兩者無異。自定義 tag 的部分,Radix Vue 幾乎所有元件都支援使用 as 來設定 HTML tag。

<ScrollArea class="h-[200px] w-[350px] rounded-md border p-4">
  <p>
    <!-- 略 -->
  </p>
</ScrollArea>

Radix Vue 與 Element Plus、PrimeVue,或是未提及的 Vuetify、Nuxt UI 不同。Radix Vue 僅提供最基本的樣式(甚至幾乎沒有樣式)與結構,讓使用者可以自由定義風格。

元件實作

在實作上,我們甚至不需要處理 Props,因此我們可以從 <template> 開始。

<div class="atomic-scrollbar">
  <div class="atomic-scrollbar__viewport">
    <slot name="default" />
  </div>

  <div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
    <div class="atomic-scrollbar__thumb" />
  </div>

  <div class="atomic-scrollbar__track atomic-scrollbar__track--horizontal">
    <div class="atomic-scrollbar__thumb" />
  </div>
</div>

在這個結構中,Root 是用來提供 Track 定位的容器;Viewport 則是承載內容的容器。這樣做可以防止 Track 元素影響使用者的內容與語義化結構。

由於我們目的是自己設計 Scrollbar,因此需要先用 CSS 隱藏瀏覽器原生的 Scrollbar。

.atomic-scrollbar {
  overflow: hidden;

  &__viewport {
    height: inherit;
    overflow: auto;
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }
  }
}

scrollbar-width: none; 可以隱藏瀏覽器的 Scrollbar,而 &::-webkit-scrollbar { display: none; } 則用於隱藏 iOS 瀏覽器的 Scrollbar。

接下來,我們讓 Track 被定位在 Root 的右側與下方,並加上樣式來美化 Scrollbar。

.atomic-scrollbar {
  position: relative;

  &__track {
    --atomic-scrollbar-track-size: 8px;
    position: absolute;
    background-color: transparent;
    border-radius: 10px;

    &--vertical,
    &--horizontal {
      right: 0;
      bottom: 0;
    }

    &--vertical {
      top: 0;
      width: var(--atomic-scrollbar-track-size);
    }

    &--horizontal {
      left: 0;
      height: var(--atomic-scrollbar-track-size);
    }

    &:hover {
      --atomic-scrollbar-track-size: 10px;
      background-color: rgba(lightgray, 0.2);
    }
  }

  &__thumb {
    background-color: rgba(gray, 0.2);
    border-radius: 10px;
  }

  &__track--vertical &__thumb {
    width: 100%;
  }

  &__track--horizontal &__thumb {
    height: 100%;
  }
}

樣式部分已經處理完畢,接下來進入較為複雜的數學計算。

計算 Thumb 的寬高

Scrollbar 分為 Y 軸與 X 軸,本篇文章以 Y 軸為範例。

首先,我們要根據 Viewport 高度與 Content(整個內容高度)的比例,計算出 Thumb 應該設置的高度。

AtomicScrollbar 比例關係

由圖片可以推導出這樣的比例關係:Content : Viewport = Track : Thumb。

我們已知 Content、Viewport 和 Track 的高度,所以我們可以藉由國中數學的比例式運算,內項相乘=外項相乘,計算出 Thumb 應該要有的高度。

$$\text{Viewport} \times \text{Thumb} = \text{Content} \times \text{Track}$$

由於畫面上的 Viewport 高度等於 Track 高度,因此可以寫成:

const viewportRef = ref<HTMLElement>();

const thumbHeight = ref(0);
const thumbWidth = ref(0);

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth } = viewport;

  thumbHeight.value = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  thumbWidth.value = (offsetWidth * offsetWidth) / viewport.scrollWidth;
}

試著計算一下,假設 Content 高度為 2000,Viewport 高度為 500,那麼算出的 Thumb 高度應該是 (500 * 500) / 2000,等於 125。

但如果 Thumb 算出的數值過小,可能會影響使用體驗,因此我們希望 Thumb 的最小高度不小於 20。

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth } = viewport;

  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;

  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);

  thumbHeight.value = height;
  thumbWidth.value = width;
}

不過這樣可能導致算出來得 Thumb 高度超過 Viewport,因此需要再進行微調。

const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const offsetHeight = viewport.offsetHeight;
  const offsetWidth = viewport.offsetWidth;

  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
  
  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);

  thumbHeight.value = height < offsetHeight ? height : 0;
  thumbWidth.value = width < offsetWidth ? width : 0;
};

大多數情況下,Content : Viewport 與 Viewport : Thumb 的比例相等。也就是說,當 Viewport 往下滾動 10%,Thumb 也會往下 10%,此時移動比率(ratioY)為 1。

但是當 originalHeight 小於 MIN_SIZE 時,我們會取用 MIN_SIZE,這樣前面的比例就會不相等。

因此,我們需要計算 Thumb 的移動比率:

let ratioY = 1;
let ratioX = 1;

const update = () => {
  // 略

  ratioY = originalHeight / (offsetHeight - originalHeight) / (height / (offsetHeight - height));
  ratioX = originalWidth / (offsetWidth - originalWidth) / (width / (offsetWidth - width));
};

用程式碼表達比較複雜,列成數學式會更易理解:

$$\text{ratioY} = \frac{\text{originalHeight}}{\left(\text{offsetHeight} - \text{originalHeight}\right)} \div \frac{\text{height}}{\left(\text{offsetHeight} - \text{height}\right)}$$

由於最終計算出的 height 只會小於或等於 offsetHeight,所以我們可以推算出 ratioY 的值會在小於等於 1 到大於 0 之間。

計算 Thumb 的位置

Thumb 的位置計算與之前類似,我們需要計算出 Thumb 的位置。

const thumbTop = ref(0);
const thumbLeft = ref(0);

const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;

  thumbTop.value = ((scrollTop * 100) / offsetHeight);
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth);
};

通過滾動距離與總滾動量的比例,可以計算出 Thumb 應該處於的位置。

$$\text{thumbTop} = \left(\frac{\text{scrollTop}}{\text{offsetHeight}}\right) \times 100$$

不過,還需要考慮到 Content : Viewport 與 Viewport : Thumb 比例不相等的情況,此時需要用到前面計算的 ratioY 與 ratioX。

$$\text{thumbTop} = \left(\frac{\text{scrollTop}}{\text{offsetHeight}}\right) \times 100 \times \text{ratioY}$$

const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;

  thumbTop.value = ((scrollTop * 100) / offsetHeight) * ratioY;
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth) * ratioX;
};

更新 Thumb 的大小、比例與位置

了解如何計算 Thumb 的高度、比例與位置後,我們可以將這些數值應用到元件上。

<div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
  <div
    class="atomic-scrollbar__thumb"
    :style="{ 
      transform: `translateY(${thumbTop}%)`, 
      height: `${thumbHeight}px`
    }"
  />
</div>

接下來,我們來探討何時計算 Thumb 的大小與位置。

更新 Thumb 的大小與比例的時機包括:元件 mounted 後、Viewport 大小改變後、內容變更後。

const update = () => {
  // 略
};

const viewportRef = ref<HTMLElement>();

let unobserve: (() => void) | null = null;

onMounted(() => {
  const observer = new ResizeObserver(update);

  observer.observe(viewportRef.value);
  unobserve = () => observer.disconnect();
})

onUpdated(update);

onBeforeUnmount(() => {
  unobserve();
  unobserve = null;
})

大多數情況下,我強烈不建議在 onUpdated 中更新響應式資料,這很可能會導致無限更新循環的問題。

在這個元件中,我們更新 Thumb 的大小與比例後會再次觸發 onUpdated,但由於更新值與第一次相同,因此不會產生無限更新循環。

滑鼠點擊 Thumb 拖曳

在處理滑鼠點擊 Thumb 拖曳前,我們先來規劃整個流程:

  1. 滑鼠在 Thumb 上按下不放。
  2. 滑鼠移動時,Thumb 跟著移動。
  3. 滑鼠放開時,Thumb 停止移動。

因此,我們需要針對 Thumb 監聽 pointerdown 事件,並在按下後開始對整個網頁監聽 pointermove 來記錄移動量,並將其應用到 Viewport 上。同時,我們也需要監聽 pointerup 事件來移除監聽的 pointermove 與 pointerup 事件。

對整個網頁監聽 pointermove 是為了確保滑鼠移出 Thumb 時仍能繼續拖曳。

onThumbPointerdown

onThumbPointerdown 的目的是記錄滑鼠按下的位置,並啟動對整個網頁的 pointermove 與 pointerup 事件監聽。

let mousePosition: number = 0;
let scrollOffset: number | null = null;

const onThumbPointerdown = (event: PointerEvent) => {
  if (event.ctrlKey || event.button !== 0) return;

  const viewport = viewportRef.value;
  if (!viewport) return;

  mousePosition = event.pageY;
  scrollOffset = viewport.scrollTop;

  document.addEventListener('pointermove', onDocumentPointermove);
  document.addEventListener('pointerup', onDocumentPointerup);
};

onDocumentPointermove

在 onDocumentPointermove 中,我們計算滑鼠移動的距離,並將其反映到 Viewport 上。

const onDocumentPointermove = (event: PointerEvent) => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const offset = (event.pageY - mousePosition) / viewport.offsetHeight;

  viewport.scrollTop = scrollOffset! + offset * viewport.scrollHeight;
};

offset 是滑鼠移動距離相對於 Viewport 高度的比值:

$$\text{offset} = \frac{\text{event.pageY} - \text{mousePosition}}{\text{viewport.offsetHeight}}$$

將這個比值乘上 Viewport 的 scrollHeight,就能得到滑鼠移動所反映的移動距離,最後將移動距離與預先記錄的 scrollOffset 相加,就得到了最終的 scrollTop。

$$\text{viewport.scrollTop} = \text{scrollOffset} + \text{offset} \times \text{viewport.scrollHeight}$$

這樣,我們就能在滑鼠移動時讓 Viewport 跟著移動了!

onDocumentPointerup

最後,當滑鼠放開時,我們要移除對 pointermove 與 pointerup 的監聽,並清空暫存變數。

const onDocumentPointerup = () => {
  document.removeEventListener('pointermove', onDocumentPointermove);
  document.removeEventListener('pointerup', onDocumentPointerup);

  currentOrientation = null;
  scrollOffset = null;
  mousePosition = 0;

  if (document.onselectstart !== originalOnSelectStart) {
    document.onselectstart = originalOnSelectStart;
  }
};

我們來看看滑鼠點擊 Thumb 拖曳的效果。

AtomicScrollbar 滑鼠點擊 Thumb 拖曳

Thumb 拖曳的部分,我們需要考量到在行動裝置上操作,在行動裝置上沒有滑鼠,也就沒有 MouseEvent,這時我們使用 PointerEvent 會是更好的選擇。

滑鼠點擊 Track 跳到指定位置

當點擊原生 Scrollbar 的 Track 時,會逐步跳到指定位置。我們希望 <AtomicScrollbar> 也具備這樣的功能。此處實作一個簡單版本,當使用者點擊 Track 時,Thumb 會直接跳到指定位置。

我們可以換算出點擊 Track 的位置對應到 Content 上的位置,然後將 Viewport 移動到該位置。

例如:Track 高度為 100,Content 高度為 1000,點擊 Track 的位置為 20,則對應的 Content 位置為 200,這表示 Viewport 要移動到 200 的位置。

$$\text{ScrollTop} = \text{Point} \times \left(\frac{\text{Content}}{\text{Track}}\right)$$

Content 的高度剛好等於 Viewport 可滾動的高度,因此程式碼實作如下:

const onTrackPointerdown = (
  event: PointerEvent,
  orientation: OrientationKey
) => {
  const viewport = viewportRef.value;
  if (!viewport) return;

  const track = event.currentTarget as HTMLElement;

  const rect = track.getBoundingClientRect();
  const point = Math.abs(rect.top - event.pageY);

  viewport.scrollTop = point * (viewport.scrollHeight / track.offsetHeight);
};

這樣一來,我們就可以在點擊 Track 時,讓 Viewport 與 Thumb 移動到指定的位置。

AtomicScrollbar 滑鼠點擊 Track 跳到指定位置

沒有互動時隱藏 Scrollbar

最後,我們希望 Scrollbar 在沒有互動時可以隱藏。

互動行為包括:滑鼠進入 Thumb、滑鼠拖曳 Thumb、滑鼠滾動。因此,我們可以使用兩個 flag 變數來記錄這些狀態。

const active = ref(false);
const dragging = ref(false);

在拖曳過程中,dragging 設為 true,而滑鼠移動或滾動時,active 設為 true。

dragging

const onDocumentPointermove = (event: PointerEvent) => {
  // 略

  dragging.value = true;
};

const onDocumentPointerup = () => {
  // 略

  dragging.value = false;
};

active

const scheduleHideScrollbar = () => {
  time && clearTimeout(time);
  time = setTimeout(() => {
    active.value = false;
  }, 1000);
};


const onScroll = () => {
  // 略

  scheduleHideScrollbar();
  active.value = true;
};

const onTrackPointerenter = () => {
  // 略

  active.value = true;
};

const onTrackPointerleave = scheduleHideScrollbar;

最後,在 Track 上加上 v-show 條件,當 active 或 dragging 為 true 時顯示,否則隱藏。

<div
  v-show="active || dragging"
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
>
  <!-- 略 -->
</div>

進階功能

減少重複的 ResizeObserver

目前每一個 Scrollbar 都會有一個獨立的 ResizeObserver。如果畫面上同時有很多 <AtomicScrollbar> 存在,將造成不少的資源浪費。

我們可以使用在 <AtomicLink> 裡面用過的「單例模式」,讓每個 <AtomicScrollbar> 都共享同一個 ResizeObserver 實例。

let unobserve: (() => void) | null = null;

onMounted(() => {
  const observer = createResizeObserver();
  unobserve = observer.observe(viewportRef.value, update);
});

onBeforeUnmount(() => {
  unobserve?.();
  unobserve = null;
});

createResizeObserver 的實作如下:

type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;

let cache: { observe: ObserveFn } | undefined;

export default function createResizeObserver() {
  if (cache) return cache;

  let observer: ResizeObserver | null = null;
  const callbacks = new Map<Element, CallbackFn>();

  const observe: ObserveFn = (element, callback) => {
    if (!observer) {
      observer = new ResizeObserver(entries => {
        for (const entry of entries) {
          const callback = callbacks.get(entry.target);
          callback?.();
        }
      });
    }

    callbacks.set(element, callback);
    observer.observe(element);

    return () => {
      callbacks.delete(element);
      observer!.unobserve(element);

      if (callbacks.size === 0) {
        observer!.disconnect();
        observer = null;
      }
    };
  };

  return (cache = { observe });
}

與 <AtomicLink> 裡面實作的 createIntersectionObserver 方式相同,第一層的 cache 確保無論呼叫多少次 createResizeObserver 都只會在第一次建立一個 { observe } 物件並在之後共用;第二層的單例模式則確保無論呼叫多少次 observe,都只會有一個 ResizeObserver 實例。

詳細拆解說明可以參考 AtomicLink 中的「減少重複的 IntersectionObserver」章節。

這樣,我們就可以減少重複的 ResizeObserver 實例,提高效能。

無障礙

對於我們看得見的人來說,畫面上那條小小的東西很容易被認為是 Scrollbar,但對於使用螢幕閱讀器的人來說,這些東西可能不易辨識。因此,我們需要透過 role 來明確告知這是一個 Scrollbar。

角色 Role

首先,我們需要為 Track 加上 role="scrollbar" 屬性,這樣螢幕閱讀器才能識別這是一個 Scrollbar。

<div
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
  role="scrollbar"
>
  <div
    class="atomic-scrollbar__thumb"
    :style="{
      transform: `translateY(${thumbTop}%)`,
      height: `${thumbHeight}px`,
    }"
  />
</div>

ARIA 屬性

為了配合 role="scrollbar",我們還需要加上一些 aria-* 屬性,這樣螢幕閱讀器可以清楚讀取 Scrollbar 的狀態。

  • aria-controls:指定 Scrollbar 控制的元素。
  • aria-orientation:指定 Scrollbar 的方向。
  • aria-valuenow:指定 Scrollbar 的當前值。
  • aria-valuemax:指定 Scrollbar 的最大值,預設為 100。
  • aria-valuemin:指定 Scrollbar 的最小值,預設為 0。

我們需要計算 aria-valuenow 的值。不同於 thumbTop 是 Thumb 移動的百分比,aria-valuenow 表示已滾動的百分比。

最簡單的算法如下:

const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;

  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});

但是這樣的數據不會隨著滾動自動更新,因為 scrollTop、offsetHeight 和 scrollHeight 都不是 Vue 追蹤的響應式變數。此時,我們可以使用一個小技巧來讓 valuenowY 隨著 Scrollbar 滾動更新。

const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;

  // 追蹤依賴
  void thumbTop.value;

  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});

$$\text{valuenowY} = \frac{\text{scrollTop}}{\text{scrollHeight} - \text{offsetHeight}} \times 100$$

computed 會在第一次運算時,收集函數中的響應式資料(如 ref、reactive 及 computed),當這些依賴變化時,computed 會重新計算。

thumbTop.value 是響應式變數,會隨著 Scrollbar 滾動更新。使用 void thumbTop.value 可以使 thumbTop.value 的變動觸發 valuenowY 的重新計算,從而達到追蹤當前滾動量的效果。

總結

在製作 <AtomicScrollbar> 的過程中,我們進行了許多數學運算,計算了 Thumb 的高度、寬度與位置,並計算了滑鼠拖曳 Thumb 的移動量。最後,在實現無障礙功能時也用到了數學運算。如果一次看不懂,建議帶入一些具體的數字來計算,這樣更容易理解。

Scrollbar 雖然不是常見的元件,但相比純 CSS 處理 Scrollbar,使用 <AtomicScrollbar> 元件可以在不同作業系統和瀏覽器上達到一致的效果,特別是解決了 Windows 上 Scrollbar 寬度約 17px 的問題。

雖然我們已盡力模擬瀏覽器原生 Scrollbar 的行為,但例如在行動裝置上,點擊 Scrollbar 的震動回饋等功能尚未完全普及(特別是在 Safari 上)。因此,使用前建議與合作夥伴充分理解這些方案的優缺點,以找到最適合的解決方案。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicDropdown
下一篇
[為你自己寫 Vue Component] AtomicAccordion / AtomicCollapse
系列文
為你自己寫 Vue Component 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言